<?php
/**
 * Wikipedia Xml Parser Classes
 *
 * Classes and Interfaces related to parsing Wikipedia's
 * xml dump.
 *
 * @author Rob Young
 * @package forage.examples.wikipedia
 */
require_once dirname(__FILE__) . '/page.php';
require_once dirname(__FILE__) . '/logging.php';

/**
 * Interface for handling what to do with a parsed wikipedia page
 */
interface WikipediaParserAction
{
  /**
   * Do somethign with a parsed WikipediaPage object
   *
   * @param WikipediaPage $page
   */
  public function parsedPage(WikipediaPage $page);
}

/**
 * Abstract base class for parsing xml documents
 */
abstract class XmlParser
{
  /**
   * An expat parser resource
   *
   * @var expat-resource
   */
  private $parser;

  /**
   * Stack showing structure of xml document at current point
   *
   * @var array
   */
  private $stack;

  /**
   * File handle of current file (if we're reading from a file)
   *
   * @var file-resource
   */
  protected $handle;

  /**
   * File name(s) of xml document.
   *
   * @var array
   */
  protected $file;

  /**
   * Flag to say whether or not verbose output should be shown
   *
   * @var boolean
   */
  protected $verbose;
  
  /**
   * Class constructor
   * Set up a parser parser reference and default values.
   */
  public function __construct()
  {
    $this->verbose = false;
    $this->stack   = array();
    $this->parser  = $this->createParser();
  }

  /**
   * Make this parser echo detailed logging
   * information.
   *
   */
  public function setVerbose()
  {
    $this->verbose = true;
  }

  /**
   * Check whether this parser is set to output detailed
   * logging.
   *
   * @return boolean
   */
  public function isVerbose()
  {
    return $this->verbose;
  }

  /**
   * Create an XPAT parser reference with this
   * object as it's handlers.
   *
   * @return expat-resource
   */
  private function createParser()
  {
    $parser = xml_parser_create();
    xml_set_object($parser, $this);
    xml_set_element_handler($parser, 'baseStartElement', 'baseEndElement');
    xml_set_character_data_handler($parser, 'baseAddCData');
    return $parser;
  }

  /**
   * Parse an XML document from a string.
   *
   * @param string $data
   */
  public function parseString($data)
  {
    xml_parse($this->parser, $data);
  }

  /**
   * Parse an xml document from one or more files.
   *
   * @param mixed $file
   * @param int $chunk
   * @param bool $last
   */
  public function parseFile($file, $chunk=1024, $last=true)
  {
    if (is_array($file)) {
      for ($i=0, $c=count($file); $i<$c; $i++) {
        if ($this->isVerbose()) {
          echo "parsing " . $file[$i] . "\n";
        }
        $this->parseFile($file[$i], $chunk, $i==($c-1));
      }
    } else {
      $fh = fopen($file, 'r');
      if ($fh) {
        $this->handle = $fh;
        $this->file   = $file;
        while ($fh && !feof($fh)) {
          xml_parse($this->parser, fread($fh, $chunk), false);
        }
      }
      if ($last) {
        xml_parse($this->parser, '', true);
      }
    }
  }

  /**
   * Helper method to put CDATA tags around a string.
   *
   * @param string $data
   * @return string
   */
  public static function cdata($data)
  {
    return '<![CDATA[' . str_replace(']]>', ']]]]><![CDATA[>', $data) . ']]>';
  }

  /**
   * Add a tag onto the tag stack.
   *
   * @param string $tag
   */
  protected function stackPush($tag)
  {
    $this->stack[] = $tag;
  }

  /**
   * Remove and return the tag on the top of the stack.
   *
   * @return string
   */
  protected function stackPop()
  {
    return array_pop($this->stack);   
  }

  /**
   * Return the tag on top of the stack.
   *
   * @return string
   */
  public function stackPeek()
  {
    $tag = $this->stackPop();
    $this->stackPush($tag);
    return $tag;
  }

  /**
   * Determine if a given tag is in the tag stack.
   *
   * @param string
   * @return boolean
   */
  public function stackHas($tag)
  {
    return in_array($tag, $this->stack);
  }

  /**
   * Base method which is called on entering a tag. startElement
   * is called from here.
   *
   * @param expat-resource $parser
   * @param string $tag
   * @param array $attributes
   */
  public function baseStartElement($parser, $tag, $attributes)
  {
    $this->stackPush($tag);
    $this->startElement($tag, $attributes);
  }
  
  /**
   * Base method which is called on leaving a tag. endElement
   * is called from here.
   *
   * @param expat-resource $parser
   * @param string $tag
   */
  public function baseEndElement($parser, $tag)
  {
    $pop = $this->stackPop();
    if ($tag != $pop) {
      trigger_error('End tag missmatch [' . $tag . ' :: ' . $pop . ']', E_USER_WARNING);
    }
    $this->endElement($tag);
  }

  /**
   * Base method which is called on coming across character data.
   * addCData is called from here.
   *
   * @param expat-resource $parser
   * @param strin $cdata
   */
  public function baseAddCData($parser, $cdata)
  {
    $this->addCData($cdata);
  }

  /* Abstract Methods */

  /**
   * Placeholder for entering a tag
   *
   * @param string $tag
   * @param array $attributes
   */
  abstract function startElement($tag, $attributes);

  /**
   * Placeholder for leaving a tag
   *
   * @param string $tag
   */
  abstract function endElement($tag);

  /**
   * Placeholder for adding cdata
   *
   * @param string $cdata
   */
  abstract function addCData($cdata);
}

/**
 * Class for parsing a Wikipedia xml dump
 * A WikipediaParserAction must be provided to
 * do something which each page parsed.
 */
class WikipediaParser extends XmlParser {
  /**
   * Handles what happens to parsed pages.
   *
   * @var WikipediaParserAction
   */
  private $target;

  /**
   * Current WikipediaPage object which is being parsed.
   *
   * @var WikipediaPage
   */
  protected $page = null;

  /**
   * Class constructor
   *
   * Set up the XML parser and set the WikipediaParserAction to handle
   * parsed pages.
   *
   * @param WikipediaParserAction $target
   */
  public function __construct(WikipediaParserAction $target)
  {
    parent::__construct();
    $this->target = $target;
  }

  /**
   * Element start callback
   *
   * If entering a 'page' element create a new WikipediaPage object.
   *
   * @param string $tag
   * @param array $attributes
   */
  public function startElement($tag, $attributes)
  {
    if ($tag == 'PAGE') {
      $this->page = new WikipediaPage();
    }
  }

  /**
   * Element end callback
   *
   * If leaving a 'page' element send WikipediaPage to WikipediaParserAction
   *
   * @param string $tag
   */
  public function endElement($tag)
  {
    if ($tag=='PAGE') {
      if (!$this->page->isRedirect()) {
        $this->parsedPage($this->page);
      }
    }
  }

  /**
   * CData callback
   *
   * If in a relavent element set WikipediaPage properties.
   *
   * @param string $cdata
   */
  public function addCData($cdata)
  {
    if ($this->stackHas('PAGE')) {
      $tag = $this->stackPeek();
      if ($this->stackHas('REVISION')) {
        switch ($tag) {
        case 'TEXT':
          $this->page->text .= $cdata;
          break;
        }
      } else {
        switch ($tag) {
        case 'TITLE':
          $this->page->title .= $cdata;
          break;
        case 'ID':
          $this->page->id .= $cdata;
          break;
        }
      }
    }
  }

  /**
   * Send a WikipediaPage to the WikipediaParserAction
   *
   * @param WikipediaPage $page
   */
  public function parsedPage(WikipediaPage $page)
  {
    $this->target->parsedPage($page);
  }
}

/**
 * Class for parsing simplified Wikipedia format
 */
class MyWikiParser extends WikipediaParser
{
  /**
   * CData callback
   *
   * If in a relavent element set WikipediaPage properties.
   *
   * @param string $cdata
   */
  public function addCData($cdata)
  {
    $tag = $this->stackPeek();
    switch ($tag) {
    case 'ID':    $this->page->id    .= $cdata; break;
    case 'TITLE': $this->page->title .= $cdata; break;
    case 'TEXT':  $this->page->text  .= $cdata; break;
    }
  }
}


/* Wikipedia Parser Actions */

/**
 * Category pattern filtering Wikipedia parser action
 *
 * This Wikipedia parser action is a decorator. It filters out
 * WikipediaPage objects which don't have categories which match
 * a given set of patterns.
 */
class CategoryPatternFilterAction implements WikipediaParserAction
{
  /**
   * The wikipedia parser which this class is decorating.
   *
   * @var WikipediaParserAction
   */
  private $target;

  /**
   * Patterns which should be matched against page categories.
   *
   * @var array
   */
  private $patterns;

  /**
   * Class Constructor
   *
   * @param WikipediaParserAction $target
   * @param array $categories
   */
  public function __construct(WikipediaParserAction $target, $patterns=array())
  {
    $this->setPatterns($patterns);
    $this->target = $target;
  }

  /**
   * Set array of patterns to match against Categories
   *
   * @param array
   */
  public function setPatterns($patterns)
  {
    if (!is_array($patterns)) {
      throw new InvalidArgumentException("Expecting an array of patterns");
    }
    $this->patterns = $patterns;
  }

  /**
   * Check if the given page has categories which match
   * any of the patterns on this filter.
   *
   * @param WikipediaPage $page
   * @return boolean
   */
  private function hasCategory(WikipediaPage $page)
  {
    $i = strpos($page->text, '[[Category:');
    if ($i===false) {
      return false;
    }
    $j = strpos($page->text, "\n\n", $i);
    if ($j === false) {
      $chunk = substr($page->text, $i);
    } else {
      $chunk = substr($page->text, $i, $j);
    }
    if (!preg_match_all('/\[Category:(.*?)\]/', $chunk, $matches)) {
      return false;
    }
    foreach ($this->patterns as $category) {
      if (preg_grep($category, $matches[1])) {
        return true;
      }
    }
    return false;
  }

  /**
   * Interface method, if one of the categories matches
   * pass the page onto the decorated action
   *
   * @param WikipediaPage $page
   */
  public function parsedPage(WikipediaPage $page)
  {
    if ($this->hasCategory($page)) {
      $this->target->parsedPage($page);
    }
  }
}

class MyWikiXmlWriterAction implements WikipediaParserAction
{
  /**
   * Target filename
   *
   * @var string
   */
  private $filename;

  /**
   * Target file resource
   * @var file-resource
   */
  private $handle;

  /**
   * Class constructor
   *
   * @param string $target
   */
  public function __construct($target)
  {
    $this->filename = $target;
    $this->handle   = fopen($target, 'w+');
    if (!$this->handle) {
      throw new Exception("Could not open file ".$target);
    }
    $this->write('<?xml version="1.0"?><pages>');
  }

  /**
   * Class destructor
   */
  public function __destruct()
  {
    $this->write('</pages>');
    fclose($this->handle);
  }

  /**
   * Interface method
   * Calls internal writePage method to write
   * the WikipediaPage to file.
   *
   * @param WikipediaPage $page
   */
  public function parsedPage(WikipediaPage $page)
  {
    $this->writePage($page);
  }

  /**
   * Write a WikipediaPage to the out file.
   *
   * @param WikipediaPage $page
   */
  private function writePage(WikipediaPage $page)
  {
    $this->write(
      '<page><id>' . $page->id . '</id>'.
      '<title>'.XmlParser::cdata($page->title) . '</title>'.
      '<text>'.XmlParser::cdata($page->text).'</text></page>'
    );
  }

  /**
   * Write some data to the out file.
   *
   * @param string $data
   */
  private function write($data)
  {
    if (fwrite($this->handle, $data)===false) {
      throw new Exception("Failed to write " . strlen($data) . " bytes to " . $this->filename);
    }
  }
}

